Effective Java 2.0_中文版_Item 2

文章作者:Tyan
博客:noahsnail.com

Item 2:当面临很多构造函数参数时,要考虑使用构建器

静态工厂和构造函数有一个共同的限制:对于大量可选参数它们都不能很好的扩展。考虑这样一种情况:用一个类来表示包装食品上的营养成分标签。这些标签有几个字段是必须的——每份含量、每罐含量(份数)、每份的卡路里,二十个以上的可选字段——总脂肪量、饱和脂肪量、转化脂肪、胆固醇、钠等等。大多数产品中这些可选字段中的仅有几个是非零值。

你应该为这样的一个类写什么样的构造函数或静态工厂?习惯上,程序员使用重叠构造函数模式,在这种模式中只给第一个构造函数提供必要的参数,给第二个构造函数提供一个可选参数,给第三个构造函数提供两个可选参数,以此类推,最后的构造函数具有所有的可选参数。下面是一个实践中的例子。为了简便,只显示了四个可选字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//Telescoping constructor pattern - does not scale well!
public class NutritionFacts {

private final int servingSize; // (mL) required
private final int servings; // (per container) required
private final int calories; // optional
private final int fat; // (g) optional
private final int sodium; // (mg) optional
private final int carbohydrate; // (g) optional

public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}

public NutritionFacts(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0);
}

public NutritionFacts(int servingSize, int servings, int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}

public NutritionFacts(int servingSize, int servings, int calories, int fat,
int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}

public NutritionFacts(int servingSize, int servings, int calories, int fat,
int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}

当你想创建一个实例时,你可以使用具有最短参数列表的构造函数,最短参数列表包含了所有你想设置的参数:

1
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

通常构造函数调用需要许多你不想设置的参数,但无论如何你不得不为它们传值。在这种情况下,我们给fat传了一个零值。只有六个参数可能还不是那么糟糕,但随着参数数目的增长它很快就会失控。

简而言之,重叠构造函数模式有作用,但是当有许多参数时很难编写客户端代码,更难的是阅读代码。读者会很奇怪所有的这些值是什么意思,必须仔细的计算参数个数才能查明。一长串同类型的参数会引起细微的错误。如果客户端偶然的颠倒了两个这样的参数,编译器不会报错,但程序在运行时会出现错误的行为(Item 40)。

当你面临许多构造函数参数时,第二个替代选择是JavaBeans模式,在这种模式中你要调用无参构造函数来创建对象,然后调用setter方法为每一个必要参数和每一个有兴趣的可选参数设置值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//JavaBeans Pattern - allows inconsistency, mandates mutability
public class NutritionFacts {
// Parameters initialized to default values (if any) private int servingSize
private int servingSize = -1; // Required; no default value
private int servings = -1;// Required; no default value
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;

public NutritionFacts() {
}

// Setters
public void setServingSize(int val) {
servingSize = val;
}

public void setServings(int val) {
servings = val;
}

public void setCalories(int val) {
calories = val;
}

public void setFat(int val) {
fat = val;
}

public void setSodium(int val) {
sodium = val;
}

public void setCarbohydrate(int val) {
carbohydrate = val;
}
}

这个模式没有重叠构造函数模式的缺点。即使有点啰嗦,但它很容易创建实例,也很容易阅读写出来的代码:

1
2
3
4
5
6
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

遗憾的是,JavaBeans模式自身有着严重缺点。因为构造过程跨越多次调用,JavaBean在构造过程中可能会出现不一致的状态。JavaBean类不能只通过检查构造函数参数的有效性来保证一致性。当一个对象处于一种不一致的状态时,试图使用它可能会引起失败,这个失败很难从包含错误的代码中去掉,因此很难调试。与此相关的一个缺点是JavaBeans模式排除了使一个类不可变的可能性*(Item 15),因此需要程序员付出额外的努力来确保线程安全。

当构造工作完成时,可以通过手动『冰冻』对象并且在冰冻完成之前不允许使用它来弥补这个缺点,但这种方式太笨重了,在实践中很少使用。而且,由于编译器不能保证程序员在使用对象之前调用了冰冻方法,因此它可能在运行时引起错误。

幸运的是,这儿还有第三种替代方法,它结合了重叠构造函数模式的安全性和JavaBeans模式的可读性。它就是构建器模式[Gamma95, p. 97]。它不直接构建需要的对象,客户端调用具有所有参数的构造函数(或静态工厂),得到一个构造器对象。然后客户端在构建器上调用类似于setter的方法来设置每个感兴趣的可选参数。最终,客户端调用无参构建方法来产生一个对象,这个对象是不可变的。构建器是它要构建的类的静态成员类(Item 22)。它在实践中的形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//Builder Pattern
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;

public static class Builder {
// Required parameters
private final int servingSize;
private final int servings;
// Optional parameters - initialized to default values
private int calories = 0;
private int fat = 0;
private int carbohydrate = 0;
private int sodium = 0;

public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}

public Builder calories(int val) {
calories = val;
return this;
}

public Builder fat(int val) {
fat = val;
return this;
}

public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}

public Builder sodium(int val) {
sodium = val;
return this;
}

public NutritionFacts build() {
return new NutritionFacts(this);
}
}

private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}

注意NutritionFacts是不可变的,所有参数的默认值都在一个单独的位置。构建器的setter方法返回的是构建器本身,为的是链式调用。客户端代码如下:

1
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohydrate(27).build();

客户端代码很容器写,更重要的是很容易读。构建器模式模拟了命名可选参数,就像Ada和Python中的一样。类似于构造函数,构造器可以对它参数加上约束条件。构造器方法可以检查这些约束条件。将参数从构建器拷贝到对象中之后,可以在对象作用域而不是构造器作用域对约束条件进行检查,这是很关键的(Item 39)。如果违反了任何约束条件,构造器方法会抛出IllegalStateException异常(Item 60)。异常的详细信息会指出违反了哪一个约束条件(Item 63)。

相比于构造函数,构建器的一个小优势在与构建器可以有许多可变参数。构造函数类似于方法,只能有一个可变参数。由于构造器用单独的方法设置每一个参数,因此像你喜欢的那样,它们能有许多可变参数,直到每个setter方法都有一个可变参数。

构建器模式是灵活的。一个构建器可以用来构建多个对象。为了改变对象,构建器参数在创建对象时可以进行改变。构建器能自动填充一些字段,例如每次创建对象时序号自动增加。

设置了参数的构建器形成了一个很好的抽象工厂[Gamma95,p.87]。换句话说,为了使某个方法能为客户端创建一个或多个对象,客户端可以传递这样的一个构建器到这个方法中。为了使这个用法可用,你需要用一个类型来表示构建器。如果你在使用JDK 1.5或之后的版本,只要一个泛型就能满足所有的构建器(Item 26),无论正在构建的是什么类型:

1
2
3
4
// A builder for objects of type T
public interface Builder<T> {
public T build();
}

注意我们可以声明NutritionFacts.Builder类来实现Builder<NutritionFacts>

带有构建器实例的方法通常使用绑定的通配符类型来约束构建器的类型参数(Item 28)。例如,构建树的方法通过使用客户端提供的构建器实例来构建每一个结点:

1
Tree buildTree(Builder<? extends Node> nodeBuilder) { ... }

Java中传统的抽象工厂实现是类对象,newInstance方法扮演着build方法的角色。 这种用法问题重重。newInstance方法总是尝试调用类的无参构造函数,但无参构造函数可能并不存在。如果类没有访问无参构造函数,你不会收到编译时错误。而客户端代码必须处理运行时的InstantiationExceptionIllegalAccessException异常,这样既不雅观也不方便。newInstance也会传播无参构造函数抛出的任何异常,即使newInstance缺少对应的抛出语句块。换句话说,Class.newInstance打破了编译时的异常检测。上面的Builder接口弥补了这些缺陷。

构建器模式也有它的缺点。为了创建对象,你必须首先创建它的构建器。虽然创建构建器的代价在实践中可能不是那么明显,但在某些性能优先关键的情况下它可能是一个问题。构建器模式比重叠构造函数模式更啰嗦,因此只有在参数足够多的情况下才去使用它,比如四个或更多。但要记住将来你可能会增加参数。如果你开始使用构造函数或静态工厂,当类发展到参数数目开始失控的情况下,才增加一个构建器,废弃的构造函数或静态工厂就像一个疼痛的拇指,最好是在开始就使用构建器。

总之,当设计的类的构造函数或静态工厂有许多参数时,构建器模式是一个很好的选择,尤其是大多数参数是可选参数的情况下。与传统的重叠构造函数模式相比,使用构建器模式的客户端代码更易读易编写,与JavaBeans模式相比使用构建器模式更安全。

如果有收获,可以请我喝杯咖啡!